Skip to content

fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key - #17067

Merged
os-bill merged 5 commits into
mainfrom
claude/issue-16365-plugin-version-semver-grammar
Sep 9, 2026
Merged

fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key#17067
os-bill merged 5 commits into
mainfrom
claude/issue-16365-plugin-version-semver-grammar

Conversation

@os-bill

@os-bill os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16365

PluginSchema.version was /^\d+\.\d+\.\d+$/ while its own describe() said "Semantic Version" with no qualifier — and SemVer 2.0.0 defines prerelease and build metadata as parts of a semantic version. The declaration refused part of what it declared. PluginLoader.isValidSemanticVersion, the check the boot path has always run, implemented the whole grammar.

Direction per the triage ruling on the card (comment 5579487348), quoted as it was written:

:177 那一行自相矛盾。 describe('Semantic Version') —— 无任何限定词 —— 而 SemVer 2.0.0 把 prerelease 与 build metadata 定义为语义版本的组成部分。⇒ 这个 key 的声明(describe)说的是全 SemVer,它的实现(regex)只收其中一个子集。

What changed

  • packages/spec/src/kernel/plugin.zod.tsversion takes the loader's grammar, character for character, not a third spelling. The two declarations now converge exactly.
  • packages/core/src/plugin-contract.ts — the stopgap version filter in assertPluginContract is gone; version is the ninth enforced key. The module comment's enumeration and its version section are re-settled.
  • packages/core/src/plugin-loader.ts — the docblock that explained the divergence now states the convergence, and that the structural checks stay the loader's own because they cover name/init, not because they judge version differently.
  • Pins in packages/spec/src/kernel/plugin.test.ts and re-settled groups E and G in packages/core/src/plugin-contract-enforcement.test.ts.
  • Changeset: .changeset/plugin-version-semver-grammar.md.

The one behaviour this narrows, stated plainly

On ObjectKernel nothing moves: validatePluginStructure already judged version with this exact grammar and still runs first, so a malformed version is still Invalid semantic version, never PLUGIN_CONTRACT_VIOLATION. On LiteKernel a plugin with a malformed version was registered before and is refused now. LiteKernel never ran the structural checks, so version was the one declared key it did not judge at all — green in vitest, refused by ObjectKernel at boot. That is the split the LiteKernel convergence closed for the other eight keys; this closes it for the ninth. Pinned as a named test.

Measurements, not assertions

Reverse verification (two ablations, both on the committed tree, both restored and re-proved).

  • A — revert the spec widening. Mutation proved on disk (wide 1 -> 0, narrow 0 -> 1), rebuilt, and proved to have reached dist via ablation-dist-preflight --absent (marker absent from all 218 built files). Result: spec plugin.test.ts 15 failed | 21 passed (from 36 passed) and core plugin-contract-enforcement.test.ts 7 failed | 29 passed (from 36 passed). Restore leg: rebuilt, preflight confirmed the marker present in 4 built files, suite back to 36 passed, whole-tree git status --porcelain empty and the source blob equal to its HEAD blob.
  • B — put the version filter back. Mutation proved on disk. Result: exactly 1 failed | 35 passed — only the new LiteKernel pin. That isolation is the point: the widening's own pins do not depend on the filter, and the filter's removal is what the new pin measures.

A PM mechanism assumption, falsified — reported because it is information. The expectation was that authorable-surface/kernel.json would move while api-surface/kernel.json would not. Neither moves, and pnpm --filter @objectstack/spec check:generated reports all 15 generated artifacts up to date, before and after merging main. Measured reason: authorable-surface/kernel.json is a ratchet of key nameskernel/Plugin:version is already on it — and records no grammar; api-surface records exports. The widening is real but lands in packages/spec/json-schema/, which is gitignored yet shipped (it is in the package's files[]), so no checked-in baseline moves. Direct reading of the published artifact:

kernel/Plugin.json  version.pattern
  before  ^\d+\.\d+\.\d+$
  after   ^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$

⚠️ Qualifier, measured, so the "invisible in baselines" reading is not overstated: the mechanical clause-② tell does fire on this diff — check-widening-tells --declaration no exits 4 with T1 plugin.zod.ts:201 — a new key on a Zod object schema. But it fires by diff shape, not by reading the grammar: version is not a new key, it is an edited line. The control settles it — feeding the gate a strict narrowing of that same line (/^[1-9]\d*\.\d+\.\d+$/) produces the identical T1 with the identical "the accept set gains a spelling" message. The gate's own header already states a tell is "never a proof", so this is it behaving as documented rather than a defect — but it does mean nothing mechanical in this repo reads the direction of a regex change. The baselines are blind to this whole class either way.

The loader's grammar is not SemVer 2.0.0 conformant — checked against the official regex over the spec's own example corpus, because it changes what "converge" means. It accepts every SemVer-valid string (no gaps), and additionally accepts 8 forms SemVer forbids: 01.1.1, 1.01.1, 1.1.01, 1.0.0-alpha..1, 1.0.0-alpha.., 1.0.0-0123, 1.0.0+., 1.0.0-.. ⭐ The decisive part: the spec's own pre-change regex already accepted the three leading-zero forms. So adopting the official SemVer regex would have narrowed this key — refusing 01.1.1, which it accepts today — which is the one thing the ruling forbids. Adopting the loader's grammar is therefore the only spelling that is a pure widening. The residual fringe is carried in a comment and in a test, and is filed separately as #17070 — out of scope here, and deliberately so: every repair for it narrows this key, which this card's ruling forbids. That card is unassigned and unlabelled, for triage.

Confidence-gap search — the one thing that would have overturned the ruling. Triage required stopping and reporting if an ADR or maintainer ruling makes plugin version deliberately narrower than SemVer. None exists. The search also turned up evidence in the opposite direction: packages/spec/src/kernel/plugin-versioning.zod.ts declares SemanticVersionSchema as "Standard SemVer format with optional pre-release and build metadata", with preRelease and build keys — the same domain, the same subject. And the control case: ManifestSchema.version keeps the narrow regex, but its prose says (major.minor.patch) explicitly and a test pins 1.0.0-beta invalid. When this repo means three-segment-only it says so and pins it; PluginSchema.version did neither.

Verification

Run under the shared verify lock; verdicts read from each run's own VERDICT line, exit codes landed to disk before reading.

Run Result
@objectstack/spec full suite 467 files / 13127 passed
@objectstack/spec typecheck pass
@objectstack/core full suite 49 files / 1213 passed
@objectstack/core typecheck pass
@objectstack/verify harness.host-resolution 7 passed — the class-based version = '0.0.0-fixture' plugin still boots a real kernel
pnpm lint (whole repo, eslint . --no-inline-config) exit 0 at the final head 30ed01ef8 (and at fbe46e3e pre-merge); 6398 files linted, 0 findings
Derived gate families dispatch-gates --ran, re-derived on 30ed01ef8: 78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN

The card's evidence base was re-verified rather than trusted: both loader pins (plugin-loader.test.ts:91, :102) and both class-based fixtures (packages/cli/test/serve-organizations-host-resolution.e2e.test.ts:67, packages/verify/src/harness.host-resolution.test.ts:46) exist as described. Triage's line numbers were stale and are not reused; every number here is re-derived on this tree.

Declared to CI, not run here. pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt both exit 3 = PREREQUISITE NOT MET in this container and say so themselves ("⛔ This is NOT a pass: nothing was measured") — both need a full 80-package build. Their --self-test halves pass. @objectstack/cli's suite is likewise NOT MEASURED: 52 files fail to collect on unbuilt-dependency module resolution (@objectstack/client, @objectstack/plugin-email, create-objectstack), none of it related to this diff, which touches no packages/cli file; the integration tier is CI's by the standing tier rule.

Clause-②: yes

Re-derived from this diff, not inherited: a published JSON Schema's accept set widens (pattern above), and LiteKernel's accept set narrows. No new error code, so packages/spec/src/api/error-code-ledger.zod.ts — contended by two open PRs — is untouched.

Patch round — contract review FAIL, repaired

Binding finding: the @objectstack/core paragraph of the changeset carried no BREAKING banner. The prose said NARROWS in as many words, but scripts/check-adr-0087-registration.mjs:572 detects breaking-ness with /\*\*BREAKING/i — so the changeset scored non-breaking and the ADR-0087 disposition marker was never judged at all. Measured before the repair: grep -c BREAKING on the changeset was 0, against 23 in packages/core/CHANGELOG.md on origin/main as the lit control.

Repaired by adding the banner in the phrasing the two 17.4.0 precedents for this exact class use, read from origin/main rather than paraphrased — "BREAKING accept-set narrowing on a published runtime entry point, shipped as minor under the repo's launch-window convention for breaking changes" — with the same not-required (no-migration-prescription) category. Neither package's level was raised (both stay minor) and the spec paragraph is untouched.

Now genuinely judged: check-adr-0087-registration --base 513c4955 --head 30ed01ef8 exit 0, listing this changeset as [BREAKING] not-required (no-migration-prescription) with its reason printed. The category was not reshaped to make it pass.

Also in this round: the version-less case in plugin-contract-enforcement.test.ts no longer counts eight keys — it now states the plugin loads because version is .optional(), which is the actual reason, and carries a comment saying so.

⚠️ Naming caveat this PR cannot fully repair. The first commit's subject says "the SemVer 2.0.0 grammar". The precise truth — which the body, the changeset, the code comment and the tests all state correctly — is that this adopts the loader's grammar, a strict superset of SemVer 2.0.0, wider than it in eight measured forms (#17070). The PR title is corrected; the commit subject stands, because changing it would need a force-push.

Re-verified on the final head 30ed01ef8 (merged origin/main once): @objectstack/core 49 files / 1213 passed, @objectstack/core typecheck pass, spec plugin.test.ts 36 passed, check:generated all 15 artifacts up to date, pnpm lint exit 0, and the 78-family gate set re-derived and re-run — identical family set, 76 green. The two that are not green are check:dual-build-cjs-loads and check:type-check-debt, both exit 3 = PREREQUISITE NOT MET in their own words, both needing a full 80-package build; their --self-test halves pass. (check:doc-formula-expressions and check:lean-entry-closure also exited 3 on first sweep for the same reason; their prerequisites were built and both then returned exit 0.)

Authored by Claude Code in session session_01MkQhmuuJAVDjmeWNixwDDH — recorded here in prose because the edit path appends its own footer.


Generated by Claude Code

os-bill and others added 3 commits September 9, 2026 04:11
…ts describe declares

`PluginSchema.version` was `/^\d+\.\d+\.\d+$/` while its `describe()` said
"Semantic Version" without qualification — and SemVer 2.0.0 defines prerelease
and build metadata as parts of a semantic version. The declaration refused part
of what it declared, and `PluginLoader.isValidSemanticVersion`, the check the
boot path has always run, accepted the whole grammar.

The spec adopts the loader's spelling character for character rather than a
third grammar, so the two declarations converge exactly. Measured: it is a
strict superset of the regex it replaces, so nothing that parsed stops parsing.

With the spellings converged, `assertPluginContract` drops the `version`
exclusion it carried as a stopgap; `version` becomes the ninth enforced key.
On `ObjectKernel` nothing moves (`validatePluginStructure` still runs first and
still owns that refusal); on `LiteKernel`, which never ran the structural
checks, a malformed `version` is refused for the first time — the last key on
which the two kernels disagreed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…nding

A release landed between writing the changeset and merging main: the two
enforcement entries that enumerate eight keys and declare the `version`
exclusion shipped in @objectstack/core@17.4.0 rather than sitting unreleased.
They describe what that release did and stay as written; the supersession is
now stated by version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/spec, touching 3 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/getting-started/quick-reference.mdx (via PluginSchema (symbol, a top-level const))
  • content/docs/plugins/anatomy.mdx (via PluginSchema (symbol, a top-level const))
What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c43bac704819ecb4ae7ecc92b7b3fa2681befe6bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 62c40ddaab6e580413eb979de398ea7cf08a7f07 — the merge of head 30ed01ef85fb5d7624013bcec5f7c991d48d1992 into base c43bac704819ecb4ae7ecc92b7b3fa2681befe6b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 62c40ddaab6e580413eb979de398ea7cf08a7f07 && git checkout 62c40ddaab6e580413eb979de398ea7cf08a7f07
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c43bac704819ecb4ae7ecc92b7b3fa2681befe6b 30ed01ef85fb5d7624013bcec5f7c991d48d1992 && git checkout -B drift-repro c43bac704819ecb4ae7ecc92b7b3fa2681befe6b && git merge --no-ff 30ed01ef85fb5d7624013bcec5f7c991d48d1992

node scripts/docs-audit/affected-docs.mjs --json c43bac704819ecb4ae7ecc92b7b3fa2681befe6b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c43bac704819ecb4ae7ecc92b7b3fa2681befe6b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Director seat adoption record — summon #20, session_01Tep4AYXZvyBA7jsvne5KZV (os-bill), 2026-09-09T06:59Z. The verdict below is adopted verbatim from an isolated contract-review subagent (explicit model = CONTRACT_REVIEW_TIER). Transcript tier check before adoption: every harness-stamped model field in the subagent transcript reads claude-fable-5-1 (75 stamps, no other value). Head re-read at posting time = fbe46e3ef7, unchanged since the review. ⛔ This seat takes no release action on this carrier (no ready flip, no auto-merge, no enqueue, no label write): the owning seat (domain:spec (seat post #6017 — vacant at last reading; the PR's claim 5595425005 names the owning session)) adopts this verdict verbatim or discards it, and acts per the state machine.


Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #17067 @ fbe46e3ef7b048417da36da7e1e0236127ac55f7

Verdict: CHANGES REQUIRED

Read-only seat. Increment = git diff origin/main...refs/pr-review/17067 (merge-base 513c4955, 6 files, +254/−63). Nothing posted, nothing edited; no test suite run. Every number below was re-derived on the PR tree, not inherited from the card, the dispatch, or the dev report.

Ruling conformance

The triage ruling (card comment 5579487348) chose widen the spec on one reading — describe('Semantic Version') is the declaration and /^\d+\.\d+\.\d+$/ a defective implementation of it — and attached two obligations: remove the version exclusion from the contract check once the spec lands, and stop-and-report if any ADR or maintainer text makes plugin version deliberately narrower than SemVer.

Derived judgments

(a) The regex. Exercised with node against the adopted regex, the official SemVer 2.0.0 regex, and the old regex:

input PR regex SemVer 2.0.0 old /^\d+\.\d+\.\d+$/
1.0.0 accept accept accept
1.0.0-alpha.1 accept accept refuse
1.0.0+build.5 accept accept refuse
1.0.0-rc.1+build accept accept refuse
01.0.0 accept refuse accept
1.0 refuse refuse refuse
v1.0.0 refuse refuse refuse
1.0.0- refuse refuse refuse
1.0.0-01 accept refuse refuse
1.0.0-alpha..1 accept refuse refuse
^1.0.0, >=1.0.0, 1.x, 1.0.0+ refuse refuse refuse

No v prefix, no ranges, no whitespace. It is a strict superset of the old regex (the test at plugin.test.ts:149-157 asserts the containment). It is not SemVer 2.0.0 and nothing wider: it over-accepts leading zeroes in the numeric core (pre-existing at this key) and degenerate prerelease/build identifiers (1.0.0-01, 1.0.0-alpha..1, 1.0.0+.) — new at the spec key, pre-existing at the boot path. I checked the dev's claim that no SemVer-exact spelling is a pure widening: the official regex refuses 01.0.0, which PluginSchema accepts today (narrows the spec door); a hybrid (\d+ core + SemVer-exact suffixes) would make assertPluginContract refuse 1.0.0-alpha..1 after validatePluginStructure accepted it, so a plugin that boots on ObjectKernel today would stop booting (narrows the boot door). Under "nothing that loads today stops loading", the loader's grammar is the unique answer. The fringe is declared in a source comment (plugin.zod.ts:177-200), pinned (plugin.test.ts:159-170), and filed as #17070 (verified: open, unlabelled, unassigned).

(b) Doors and comparators. PluginSchema has exactly one non-test caller on this tree: packages/core/src/plugin-contract.ts:162. Doors that widen: (1) PluginSchema.safeParse as a published spec export; (2) assertPluginContractLiteKernel.use() (lite-kernel.ts:73) and ObjectKernel.use() via PluginLoader.loadPlugin (plugin-loader.ts:175 structure → :178 contract → :181 compat); (3) the shipped JSON Schema packages/spec/json-schema/kernel/Plugin.json version.pattern — root .gitignore:63 ignores the tree and packages/spec/package.json files[] ships it, so the pattern change is real, shipped, and invisible in-tree; I re-derived it from the source regex, not from a build. Not doors: os validate parses ObjectStackDefinitionSchema, whose plugins is z.array(z.unknown()) (packages/spec/src/stack.zod.ts:724); os plugin build / os package publish run ManifestSchema (plugin/build.ts:28,111), whose version keeps the narrow regex (manifest.zod.ts:314) — unchanged, out of scope; authorable-surface/kernel.json:435 records only the name kernel/Plugin:version, so no baseline moves.

Comparators: the only version comparator in packages/core|cli|runtime is SemanticVersionManager (packages/core/src/dependency-resolver.ts): parse :20-40 admits the same grammar plus a v prefix; compare :57-72 orders prerelease by localeCompare (:68), which is not SemVer §11 precedence (alpha.10 sorts below alpha.2; numeric-vs-alphanumeric rule absent); findBestVersion :354-357 sorts with it. It is only export * from './dependency-resolver.js' (core/src/index.ts:112) — no boot-path consumer in core, runtime or cli. checkVersionCompatibility (plugin-loader.ts:452-467) only re-runs validity; plugin-registration.ts:56-57,97-98 only renders versions in a supersede warning; cli hits (doctor.ts:1838, migrate/meta.ts:334-360) are Node and protocol versions. No consumer compares or sorts plugin version on any boot path, so prerelease ordering is not exercised by this widening. The localeCompare defect is pre-existing and unwired — boundary flag, not this PR's.

(c) @objectstack/core. One code change: plugin-contract.ts drops the version filter and takes result.error.issues[0] (:167). Reason: with the spellings converged the filter had nothing to filter. Effect on ObjectKernel: none observable — validatePluginStructure runs first and still refuses v1.0.0 as Invalid semantic version (pinned, group E). Effect on LiteKernel: a malformed version was registered before and is refused now with PLUGIN_CONTRACT_VIOLATION at 'version' (pinned, group G new test). One second-order effect not stated anywhere: on LiteKernel a plugin with a malformed version and another bad key is now reported at 'version' where before it was reported at the next key — message text only; not a finding. plugin-loader.ts:420-447 is docblock only.

(d) Exports / keys / codes / ledgers. No new exports (grep of +export in the diff: none). No new error code; PLUGIN_CONTRACT_VIOLATION is reused for a new key, and its ledger row (error-code-ledger.zod.ts:863-870) describes it generically with no key enumeration, so nothing there went stale; error-code-ledger.zod.ts untouched (contended by #17015/#16783 — correctly avoided). ADR-0087: the changeset carries <!-- adr-0087: not-required (no-migration-prescription) ... --> — but see F1 for why the gate never evaluates it. Liveness ledger: packages/spec/liveness/ has no kernel-plugin type; nothing owed. Fixture claims verified: plugin-loader.test.ts:91,:102; packages/cli/test/serve-organizations-host-resolution.e2e.test.ts:67; packages/verify/src/harness.host-resolution.test.ts:46. No in-repo non-SemVer plugin version literal in examples/templates/docs that the LiteKernel narrowing would newly refuse.

Semver / changeset

  • Clause-②: yes — mandatory for a published accept-set widening; the card claim (comment 5595425005) carries Clause-②: yes in the exact CLAUSE2_KEY_LINE shape (scripts/pm/check-clause2-carriers.mjs:521, tested). Carriers: needs:contract-review present on both the card and the PR (read from the label lists). The PR body's ## Clause-②: yes heading does not match CLAUSE2_KEY_LINE (no # prefix admitted) — only CLAUSE2_NEAR_MISS_LINE (:529); the designated declaration carrier is the card claim (:593-601), and the level-axis gate reads the label and/or the line, so no gate is affected (F4).
  • Levels: @objectstack/spec: minor, @objectstack/core: minor — not patch. Level axis (check-changeset-no-major.mjs:740-830): clause-② yes ⇒ at least one src/**-moved package graded minor+ — both are. Spec: additive widening ⇒ at least minor per the 2026-09-04 maintainer ruling — correct.
  • Core grades minor correctly under the launch window, but the changeset omits the window's mandatory breaking-ness carrier. The same script's header: "The mandatory information carriers for breaking-ness in the meantime are the BREAKING banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition ... during the window they are the only signal there is." check-adr-0087-registration.mjs breakingDeclaration() (:569-572) recognises only a major bump, a **BREAKING marker, or a ! summary — this changeset has none, so it is classified non-breaking (G1) and its ADR-0087 marker is never judged as a breaking disposition. Precedent in the same file: both 17.4.0 entries for this exact class — kernel.use() enforcement (packages/core/CHANGELOG.md:75) and LiteKernel.use() enforcement (:112) — open with **BREAKING** accept-set narrowing on a published runtime entry point, shipped as minor under the repo's launch-window convention. This changeset says "NARROWS LiteKernel" (line 21) and carries a Migration paragraph, but no banner. That is F1.
  • Governed paths in the diff: none (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** all untouched). CHANGELOG.md deliberately not rewritten — correct per Documentation Guardrails.

Boundary flags

  • Baseline-based review is blind to this class (director asked for a plain statement). Verified: authorable-surface is a name ratchet (kernel.json:435), api-surface records exports, and the artifact that actually widens (json-schema/kernel/Plugin.json) is gitignored yet shipped. A +0/−0 on every checked-in baseline is therefore consistent with a published-contract widening. For any .regex() / .min() / .enum change on a .zod.ts key, the reviewable evidence is the source diff and the rebuilt JSON Schema, not the baselines — a reviewer reading baselines alone would have passed this as "no contract change". Same family as [finding] @objectstack/spec pins 5309 exports by name-and-kind but only 27 by signature — 99.5% of the ratified surface passes green through a shape change #16045.
  • Three sibling spec keys keep /^\d+\.\d+\.\d+$/ under SemVer-flavoured prose and now disagree with PluginSchema.version: plugin-validator.zod.ts:144 (PluginMetadataSchema.version, describe 'Semantic version (e.g., 1.0.0)', and :126 says it "aligns with and extends the existing PluginSchema" — on this key it no longer does), plugin-registry.zod.ts:158, metadata-plugin.zod.ts:649. No runtime consumer in core/runtime/cli. Same defect class as this card; not covered by [finding] PluginSchema.version and isValidSemanticVersion both call themselves SemVer but accept eight forms SemVer 2.0.0 forbids #17070. A card is owed (F6).
  • The dev's "control case" claim that ManifestSchema.version's declaration, implementation and pin all agree is inaccurate: manifest.zod.ts:312 carries @example "2.1.0-beta.1", which :314's regex refuses. The prose qualifier at :309 and the pin at manifest.test.ts:29 still stand, so the ruling here is unaffected, but [finding] PluginSchema.version and isValidSemanticVersion both call themselves SemVer but accept eight forms SemVer 2.0.0 forbids #17070 cites it as the precedent for the "qualify the prose" option and should know the example contradicts (F5).
  • SemanticVersionManager.compare prerelease ordering (dependency-resolver.ts:68) is not SemVer precedence. Pre-existing, unwired; if a future card wires DependencyResolver into a boot path, prerelease inputs — which the loader has always admitted — will sort wrongly. Not this PR's.

Findings

  • F1 — blocking. .changeset/plugin-version-semver-grammar.md:21 declares an accept-set narrowing on a published runtime entry point (LiteKernel.use()) without the **BREAKING** banner that scripts/check-changeset-no-major.mjs (header, "CHOOSING BETWEEN THE TWO LEVELS") names as the window's mandatory carrier and that check-adr-0087-registration.mjs:569-572 keys on. Both 17.4.0 entries for this class (packages/core/CHANGELOG.md:75, :112) carry it. Fix: prefix the @objectstack/core paragraph with **BREAKING** accept-set narrowing on a published runtime entry point (\LiteKernel.use()`), shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`).and re-run the ADR-0087 gate so the existingnot-required` marker is actually judged.
  • F2 — non-blocking. packages/core/src/plugin-contract-enforcement.test.ts:524 — test name 'a version-less plugin loads — \version` is not among the eight keys'is now false:versionis among the nine; the plugin loads because the key is.optional()`. Rename.
  • F3 — non-blocking. Changeset line 41 and plugin.zod.ts:192-193 say this change "neither introduced nor widened that fringe". True for leading zeroes; not true for the @objectstack/spec accept set on 1.0.0-0123, 1.0.0-alpha..1, 1.0.0+. — the old regex admitted no suffix at all, so those are newly accepted by the spec key. What is true is that the boot path already accepted them. Reword to "not new to the boot path".
  • F4 — non-blocking. PR body line ## Clause-②: yes is a near-miss for CLAUSE2_KEY_LINE (check-clause2-carriers.mjs:521). No gate depends on the body line (the card claim is the carrier, and the label is present), but the machine-readable form is a bare Clause-②: yes line.
  • F5 — non-blocking (dev report accuracy). See boundary flag on manifest.zod.ts:312.
  • F6 — non-blocking (out of scope, card owed). See boundary flag on plugin-validator.zod.ts:144, plugin-registry.zod.ts:158, metadata-plugin.zod.ts:649.

Dev report open_questions: none listed. deviations, each answered: (1) baseline falsification — verified and agreed; consequence stated above. (2) Stopgap location — verified at plugin-contract.ts:157 on origin/main; the correction is right. (3) 17.4.0 release mid-task — verified; superseding by version and leaving CHANGELOG.md untouched is the correct discipline. (4) Rejecting the official regex — re-derived (table above); the loader's grammar is the unique pure widening. (5) Lock timeouts — process only; no effect on the increment. (6) Footer — PR body carries exactly one _Generated by_ footer; first line Fixes #16365. (7) #17070 not numbered in source comments — acceptable; the card exists and the prose is accurate.

CI at read time

Head fbe46e3ef7b048417da36da7e1e0236127ac55f7: 50 check runs, 34 latest-per-name — 29 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failed, 0 pending. Check Changeset, Spec property liveness, Governed Surface Queue Guard, Type Check · debt ledger, all six Test Core shards green. mergeable_state: clean; PR is a draft with needs:contract-review. Note that Check Changeset green is consistent with F1: a changeset with no breaking declaration is "not the ADR-0087 gate's business", which is exactly why the missing banner is invisible to CI.

Implemented-by: branch claude/issue-16365-plugin-version-semver-grammar
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)

{"pr":17067,"head":"fbe46e3ef7b048417da36da7e1e0236127ac55f7","verdict":"CHANGES REQUIRED","blocking":["F1: changeset declares a LiteKernel.use() accept-set narrowing without the BREAKING banner the launch-window convention and check-adr-0087-registration.mjs key on (.changeset/plugin-version-semver-grammar.md:21)"],"clause2":"yes","semver_ok":false,"governed":false,"ci":"34 latest-per-name: 29 success, 5 skipped, 0 failed, 0 pending"}


Generated by Claude Code

os-bill and others added 2 commits September 9, 2026 07:21
…the gate reads

check-adr-0087-registration detects breaking-ness by matching /\*\*BREAKING/,
so a changeset that says "NARROWS" only in prose is scored non-breaking and its
ADR-0087 disposition marker is never judged at all. The core paragraph now
carries the banner in the phrasing the two 17.4.0 precedents for this exact
class use; both package levels stay minor and the spec paragraph is untouched.

Also corrects a test title that still counted eight keys: a version-less plugin
loads because `version` is `.optional()`, not because it is unenforced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
@os-bill os-bill changed the title fix(spec, core): give PluginSchema.version the SemVer 2.0.0 grammar its describe declares fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key Sep 9, 2026
@os-bill
os-bill marked this pull request as ready for review September 9, 2026 11:41
@os-bill
os-bill added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 07150b3 Sep 9, 2026
45 checks passed
@os-bill
os-bill deleted the claude/issue-16365-plugin-version-semver-grammar branch September 9, 2026 12:08

Copy link
Copy Markdown
Collaborator

Post-merge contract audit at CONTRACT_REVIEW_TIERVerdict: PASS WITH FINDINGS (audit reading; director seat, summon #18 segment 5, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T12:4xZ)

PR #17067 · merged head 30ed01ef85fb5d7624013bcec5f7c991d48d1992 (re-read at posting 12:49:25Z: merged 12:07:59Z by os-bill, squash 07150b33a5) · reviewed 12:38Z–12:46Z · verdicts of record before this one: the seat's in-seat tier FAIL (card #16365 comment 5597638554) and the director's CHANGES REQUIRED (5597567274), both on the pre-patch head fbe46e3ef7; no PASS of any tier existed on the merged head when the carriers came off at 07:51Z — incident row 7 on #17040 (5601992331).

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (64 harness model stamps, all claude-fable-5-1, zero residue; positive control 57 assistant / 43 user role tokens), adopted verbatim below.
  • Implemented-by: branch claude/issue-16365-plugin-version-semver-grammar (mode:subagent dev) under the domain:spec seat session_01MkQhmuuJAVDjmeWNixwDDH (os-bill) — the same session that ran the FAIL review, discharged it, stripped the carriers and merged.
  • Outcome: the merged tree is contract-correct; the must-do item is discharged (verified by running both gates, not by reading); the seat's "text-only delta" claim holds exactly (two hunks). Owed on main: F1 a docs-only correction of one false changeset sentence before the next Version PR consumes it; F2 a card for the three sibling version keys that keep the narrow regex. ⛔ This seat touched no PR state.

Verdict: PASS WITH FINDINGS — no contract defect on the merged head; the FAIL's must-do is genuinely discharged; the residue is one factual inaccuracy in the still-pending changeset (shippable as a docs-only correction before the next Version PR consumes it), two non-binding items left undone, and process findings on how the carriers were cleared.

Head reviewed: PR head 30ed01ef85fb5d7624013bcec5f7c991d48d1992 (refs/pm-review/17067, confirmed). Squash merge commit 07150b33a50f60189dd833c22e794c9491c42c60, merged_at 2026-09-09T12:07:59Z by os-bill (commit authored 11:42:45Z on enqueue). git diff 07150b33^ 07150b33 is byte-identical to git diff c43bac70 30ed01ef — 6 files, +259/−64. Changeset still present on origin/main (ce7bae8b), not yet consumed.

FAIL-item status (verdict 5597638554 @ fbe46e3ef7; director's CHANGES REQUIRED 5597567274 on the same head)

Must-do F1 — discharged, verified by execution, not by reading.

  • .changeset/plugin-version-semver-grammar.md:21 now opens the core paragraph with **BREAKING** accept-set narrowing on a published runtime entry point, shipped as \minor` under the repo's launch-window convention…+A plugin object `LiteKernel` accepted before can be refused now.grep -c BREAKING` = 1 (was 0).
  • One ADR-0087 disposition, :45: <!-- adr-0087: not-required (no-migration-prescription) … -->.
  • node scripts/check-adr-0087-registration.mjs --base c43bac70 --head 30ed01efexit 0, 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING] not-required (no-migration-prescription). The marker is now judged, not skipped (breakingDeclaration() :569-575 keys on /\*\*BREAKING/i).
  • node scripts/check-changeset-no-major.mjs --base c43bac70 --head refs/pm-review/17067exit 0, no major; level axis N/A locally (no PR event) — the PR-run Check Changeset was success on 30ed01ef.
  • Levels unchanged: @objectstack/spec: minor, @objectstack/core: minor (both private: false).

Non-binding items:

Item Status on merged head
Seat (a) / director F2 — stale test title :524 Done: plugin-contract-enforcement.test.ts:524-528, title now "among the nine keys, but it is .optional()" + reason comment
Seat (b) — PR title / commit subject overclaim "SemVer 2.0.0 grammar" PR title corrected on GitHub; squash merge 07150b33 carries the corrected title, so the inaccurate subject of 9e84ca8b is not on main
Seat (c) / director F6 — three sibling keys keep the narrow regex Not done: plugin-validator.zod.ts:144, plugin-registry.zod.ts:158, metadata-plugin.zod.ts:649 unchanged; #17070 body unchanged since 05:50Z (no line added); no separate card found
Director F3 — "neither introduced nor widened that fringe" is false for the spec key Not done: changeset :41 and plugin.zod.ts:191-193 still say it (see F1 below)
Director F4 — PR body ## Clause-②: yes heading shape Not done; moot post-merge, no gate depended on it
Director F5 — manifest.zod.ts:312 @example "2.1.0-beta.1" contradicts :314 regex Informational; #17070 still cites ManifestSchema as a clean precedent without noting it

Delta summary — the "text-only" claim holds

git diff fbe46e3e 30ed01ef is 85 files / +7188 −474, but all of that except one commit is the origin/main merge at c43bac70. Main did not move any of the six PR files, nor anything under packages/core/src or packages/spec/src/kernel, between the two bases (513c4955c43bac70: empty diff on those paths), and merge commit 30ed01ef itself changes none of the six files vs its first parent. PR-owned delta = commit 4ac1c46d, exactly two hunks:

  1. .changeset/plugin-version-semver-grammar.md:21 — banner sentence + second sentence inserted; no other line.
  2. packages/core/src/plugin-contract-enforcement.test.ts:524-528it(...) title + 4 comment lines.

No code, schema, export, or assertion moved. The seat's stated delta (banner, test title, PR title) is exact.

Clause-② reading (re-derived on the merged head)

  • Widening, @objectstack/spec: plugin.zod.ts:201 version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/) — byte-identical to plugin-loader.ts:472. Probed directly: 0 strings the old regex accepted are refused (01.1.1 survives); newly accepted at the spec key: every -prerelease/+build suffix, including SemVer-invalid 1.0.0-0123, 1.0.0-alpha..1, 1.0.0+.. Refused as before: 1.0, 1, v1.0.0, 1.0.0-, 1.0.0+, ranges, whitespace, empty. Shipped artifact: packages/spec/json-schema/kernel/Plugin.json version.pattern (gitignored, in files[]) — invisible in every checked-in baseline; authorable-surface/kernel.json is a name ratchet. describe unchanged.
  • Narrowing, @objectstack/core: plugin-contract.ts:167 takes result.error.issues[0] — the version filter is gone. LiteKernel.use() (lite-kernel.ts:73) runs only assertPluginContract, so on LiteKernel every plugin whose version is present and outside the grammar (a leading v, 1.0, a non-string) was registered before and is refused now with PLUGIN_CONTRACT_VIOLATION … at 'version'. ObjectKernel unchanged: validatePluginStructure (plugin-loader.ts:175) runs first and still refuses with Invalid semantic version. Stated for operators: changeset :37 (what moves, per kernel), :43 Migration (spell MAJOR.MINOR.PATCH with optional -prerelease/+build, or drop the key; the refusal names plugin and key), plus the machine-readable banner and marker. Heuristic grep found no in-repo non-test plugin literal the narrowing newly refuses; Test Core green in queue.
  • No new export, no new error code, error-code-ledger.zod.ts untouched (contended file correctly avoided). PLUGIN_CONTRACT_VIOLATION in packages/runtime/src/dispatcher-error-vocabulary.ts:316 is a code list only — no key enumeration, nothing stale.
  • Tests: plugin.test.ts:101-170 — 14 previously-refused SemVer forms accepted (:118-127), 4 plain-release controls (:130-135), 7 refused forms (:137-142), strict-superset containment (:144-157), fringe pin (:159-170). plugin-contract-enforcement.test.ts group E (:346-393): three versions load on ObjectKernel, schema itself accepts them, v1.0.0 still the loader's refusal not the contract's; group G (:493-533): three versions load on LiteKernel, new pin v1.0.0 refused with message + at 'version' + code asserted (:519-522), version-less loads. null-on-key is pinned generically (:453, on author), not on version specifically — the changeset's :33 claim rests on .optional() semantics; acceptable.
  • Docs: content/docs/references/kernel/plugin.mdx:34 is generated and renders only the describe text (Semantic Version) — not stale, no pattern rendered. plugins/anatomy.mdx:117-119 and getting-started/quick-reference.mdx (the two drift-bot hits) state no grammar. protocol/kernel/plugin-spec.mdx:470-490 covers dependency constraints, not the key. Nothing owed.

Governed surface / protocol label

None of the six files is governed (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/** untouched); Governed Surface Queue Guard success on both the PR head and the merge group. plugin.zod.ts lives in packages/spec/src/kernel/, which no protocol:* glob in .github/labeler.yml covers (data/ui/system/ai only). PR labels: documentation (from .changeset/*.md**/*.md), size/m, tests, tooling; no protocol:* — consistent with config, not an omission on this PR (the kernel subtree's lack of a protocol label is a labeler gap, not this card's).

CI

  • PR head 30ed01ef: 45 runs, 35 latest-per-name — 30 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke opt-in), 0 failed, 0 pending. Lint & Repo Gates, all Type Check lanes, all six Test Core shards, Check Changeset, Spec property liveness green.
  • Merge-queue run (merge_group, gh-readonly-queue/main/pr-17067-0da638cd…, head 07150b33, 11:43:06Z): CI (17 jobs), Lint & Type Check (6 jobs incl. TypeScript Type Check), Spec Liveness Check, Governed Surface Guardall success.
  • Post-merge push runs on 07150b33 show 18 cancelled and TypeScript Type Check: failure — the rollup's "Verify every type-check lane succeeded" step failed because its lanes were concurrency-cancelled by the next main pushes (9cdffbe3, ce7bae8b, 12:08-12:10Z). Not a type error: TypeScript Type Check is success on both 9cdffbe3 and ce7bae8b, the first two main heads containing this merge.
  • node scripts/pm/check-clause2-carriers.mjs --pair 17067exit 2: "PR fix(spec, core): give PluginSchema.version the loader grammar its describe declares, and enforce it as the ninth key #17067 is not open … pair could not be formed … ⛔ Not a clearance". Expected post-merge; the seat's 07:51Z exit-0 reading cannot be reproduced now.

Findings

  • F1 (docs accuracy, follow-up recommended before the next Version PR). .changeset/plugin-version-semver-grammar.md:41 — "this release neither introduced nor widened that fringe" — and plugin.zod.ts:191-193 — "a fringe this change neither introduces nor widens" — are false for the @objectstack/spec accept set: 1.0.0-0123, 1.0.0-alpha..1, 1.0.0+. were refused by the old regex and are accepted now (probed). True only of the numeric-core leading-zero half and of the boot path. This was the director's non-blocking F3, instructed to be taken in the patch round; it was not, and the sentence will ship into packages/core/CHANGELOG.md as written. Fix: reword to "not new to the boot path; new at the spec key for the prerelease/build classes" — a docs-only PR touching the changeset and the comment.
  • F2 (card owed, not filed). plugin-validator.zod.ts:144 (PluginMetadataSchema.version, describe "Semantic version (e.g., 1.0.0)", :126 claims alignment with PluginSchema — no longer true on this key), plugin-registry.zod.ts:158, metadata-plugin.zod.ts:649 keep /^\d+\.\d+\.\d+$/. Named by both reviews (seat item c, director F6) as needing a card or a line on [finding] PluginSchema.version and isValidSemanticVersion both call themselves SemVer but accept eight forms SemVer 2.0.0 forbids #17070; neither exists (#17070 unchanged since 05:50Z; search found nothing). Same defect class as this card, not in any runtime path.
  • F3 (process — carrier cleared without a verdict on the head). Carriers were stripped 07:51:52Z (PR) / 07:51:53Z (card) at head 30ed01ef with no PASS of any tier on the PR (get_reviews empty) or the card; the only verdicts of record are FAIL and CHANGES REQUIRED, both pinned to fbe46e3e. contract-review.md L22 ("head moved or no verdict ⇒ re-hang") and L41 ("landing pre-check ① — an in-seat PASS on record") were not met. This audit confirms the delta was two textual hunks, so the substantive exposure is nil — the record, not the code, is what is deficient.
  • F4 (process — self-clearance). See Acceptance notes.
  • F5 (informational). [finding] PluginSchema.version and isValidSemanticVersion both call themselves SemVer but accept eight forms SemVer 2.0.0 forbids #17070 cites ManifestSchema.version as a clean "qualify the prose" precedent; manifest.zod.ts:312 carries @example "2.1.0-beta.1", which :314 refuses (director F5). Worth one line on [finding] PluginSchema.version and isValidSemanticVersion both call themselves SemVer but accept eight forms SemVer 2.0.0 forbids #17070 when F2 is filed.

Acceptance notes

  • Reviewed-by (FAIL, card 5597638554): an isolated contract-review-tier subagent spawned by the same session session_01MkQhmuuJAVDjmeWNixwDDH; the patch round dispatch (5598214399), the discharge (5598248181), the carrier strip, the ready flip (11:41:27Z) and the enqueue (11:42:45Z) were all that session. The dev and the clearing reviewer are therefore the same parent session; only the machine-readable pair (branch vs seat) makes it read as non-self. The one review from a different session — the director's CHANGES REQUIRED (session_01Tep4AYXZvyBA7jsvne5KZV, 5597567274) — exists only at the pre-patch head and was never re-issued.
  • Direction conforms to the triage ruling (5579487348): widen the spec, drop the exclusion, no ADR or maintainer text narrowing plugin version (counter-evidence at plugin-versioning.zod.ts). Serial constraint spec: PluginSchema makes staticPath / slug really required for type: "ui" (superRefine), and core's Plugin interface derives from PluginDefinition (spec half of #16049) #16334 was closed before the claim. Fixes #16365 is the only closing keyword; card closed at merge (12:08:00Z), pm:dispatched/assignee cleared 12:33Z.
  • Follow-ups owed on main: F1 docs-only correction (before the Version PR consumes the changeset), F2 card. Neither reopens the contract question settled here.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] PluginSchema.version refuses the prerelease and build-metadata forms SemVer defines, while the loader that actually runs accepts them

3 participants